import { toRaw } from 'vue';
import cloneDeep from 'lodash/cloneDeep';
import flattenDeep from 'lodash/flattenDeep';
import compact from 'lodash/compact';
import { JSONPath } from 'jsonpath-plus';
import transform from 'lodash/transform';
import isObject from 'lodash/isObject';
import isArray from 'lodash/isArray';
import isEqual from 'lodash/isEqual';
import difference from 'lodash/difference';
import mergeWith from 'lodash/mergeWith';
import { splitObjectPath, joinObjectPath } from '@shell/utils/string';
import { addObject } from '@shell/utils/array';

export function set(obj, path, value) {
  let ptr = obj;

  if (!ptr) {
    return;
  }

  const parts = splitObjectPath(path);

  for (let i = 0; i < parts.length; i++) {
    const key = parts[i];

    if ( i === parts.length - 1 ) {
      ptr[key] = value;
    } else if ( !ptr[key] ) {
      // Make sure parent keys exist
      ptr[key] = {};
    }

    ptr = ptr[key];
  }

  return obj;
}

export function getAllValues(obj, path) {
  const keysInOrder = path.split('.');
  let currentValue = [obj];

  keysInOrder.forEach((currentKey) => {
    currentValue = currentValue.map((indexValue) => {
      if (Array.isArray(indexValue)) {
        return indexValue.map((arr) => arr[currentKey]).flat();
      } else if (indexValue) {
        return indexValue[currentKey];
      } else {
        return null;
      }
    }).flat();
  });

  return currentValue.filter((val) => val !== null);
}

export function get(obj, path) {
  if ( !path) {
    throw new Error('Cannot translate an empty input. The t function requires a string.');
  }
  if ( path.startsWith('$') ) {
    try {
      return JSONPath({
        path,
        json: obj,
        wrap: false,
      });
    } catch (e) {
      console.log('JSON Path error', e, path, obj); // eslint-disable-line no-console

      return '(JSON Path err)';
    }
  }
  if ( !path.includes('.') ) {
    return obj?.[path];
  }

  const parts = splitObjectPath(path);

  for (let i = 0; i < parts.length; i++) {
    if (!obj) {
      return;
    }

    obj = obj[parts[i]];
  }

  return obj;
}

export function remove(obj, path) {
  const parentAry = splitObjectPath(path);

  // Remove the very last part of the path

  if (parentAry.length === 1) {
    obj[path] = undefined;
    delete obj[path];
  } else {
    const leafKey = parentAry.pop();
    const parent = get(obj, joinObjectPath(parentAry));

    if ( parent ) {
      parent[leafKey] = undefined;
      delete parent[leafKey];
    }
  }

  return obj;
}

/**
 * `delete` a property at the given path.
 *
 * This is similar to `remove` but doesn't need any fancy kube obj path splitting
 * and doesn't use `Vue.set` (avoids reactivity)
 */
export function deleteProperty(obj, path) {
  const pathAr = path.split('.');
  const propToDelete = pathAr.pop();

  // Walk down path until final prop, then delete final prop
  delete pathAr.reduce((o, k) => o[k] || {}, obj)[propToDelete];
}

export function getter(path) {
  return function(obj) {
    return get(obj, path);
  };
}

export function clone(obj) {
  return cloneDeep(obj);
}

export function isEmpty(obj) {
  if ( !obj ) {
    return true;
  }

  return !Object.keys(obj).length;
}

/**
 * Checks to see if the object is a simple key value pair where all values are
 * just primitives.
 * @param {any} obj
 */
export function isSimpleKeyValue(obj) {
  return obj !== null &&
    !Array.isArray(obj) &&
    typeof obj === 'object' &&
    Object.values(obj || {}).every((v) => typeof v !== 'object');
}

/*
returns an object with no key/value pairs (including nested) where the value is:
  empty array
  empty object
  null
  undefined
*/
export function cleanUp(obj) {
  Object.keys(obj).map((key) => {
    const val = obj[key];

    if ( Array.isArray(val) ) {
      obj[key] = val.map((each) => {
        if (each !== null && each !== undefined) {
          return cleanUp(each);
        }
      });
      if (obj[key].length === 0) {
        delete obj[key];
      }
    } else if (typeof val === 'undefined' || val === null) {
      delete obj[key];
    } else if ( isObject(val) ) {
      if (isEmpty(val)) {
        delete obj[key];
      }
      obj[key] = cleanUp(val);
    }
  });

  return obj;
}

export function definedKeys(obj) {
  const keys = Object.keys(obj).map((key) => {
    const val = obj[key];

    if ( Array.isArray(val) ) {
      return `"${ key }"`;
    } else if ( isObject(val) ) {
      // no need for quotes around the subkey since the recursive call will fill that in via one of the other two statements in the if block
      return ( definedKeys(val) || [] ).map((subkey) => `"${ key }".${ subkey }`);
    } else {
      return `"${ key }"`;
    }
  });

  return compact(flattenDeep(keys));
}

export function diff(from, to) {
  from = from || {};
  to = to || {};

  // Copy values in 'to' that are different than from
  const out = transform(to, (res, toVal, k) => {
    const fromVal = from[k];

    if ( isEqual(toVal, fromVal) ) {
      return;
    }

    if ( Array.isArray(toVal) || Array.isArray(fromVal) ) {
      // Don't diff arrays, just use the whole value
      res[k] = toVal;
    } else if ( isObject(toVal) && isObject(from[k]) ) {
      res[k] = diff(fromVal, toVal);
    } else {
      res[k] = toVal;
    }
  });

  const fromKeys = definedKeys(from);
  const toKeys = definedKeys(to);

  // Return keys that are in 'from' but not 'to.'
  const missing = difference(fromKeys, toKeys);

  for ( const k of missing ) {
    set(out, k, null);
  }

  return out;
}

/**
 * Super simple lodash isEqual equivalent.
 *
 * Only checks root properties for strict equality
 */
function isEqualBasic(from, to) {
  const fromKeys = Object.keys(from || {});
  const toKeys = Object.keys(to || {});

  if (fromKeys.length !== toKeys.length) {
    return false;
  }

  for (let i = 0; i < fromKeys.length; i++) {
    const fromValue = from[fromKeys[i]];
    const toValue = to[fromKeys[i]];

    if (fromValue !== toValue) {
      return false;
    }
  }

  return true;
}

export { isEqualBasic as isEqual };

export function changeset(from, to, parentPath = []) {
  let out = {};

  if ( isEqual(from, to) ) {
    return out;
  }

  for ( const k in from ) {
    const path = joinObjectPath([...parentPath, k]);

    if ( !(k in to) ) {
      out[path] = { op: 'remove', path };
    } else if ( (isObject(from[k]) && isObject(to[k])) || (isArray(from[k]) && isArray(to[k])) ) {
      out = { ...out, ...changeset(from[k], to[k], [...parentPath, k]) };
    } else if ( !isEqual(from[k], to[k]) ) {
      out[path] = {
        op: 'change', from: from[k], value: to[k]
      };
    }
  }

  for ( const k in to ) {
    if ( !(k in from) ) {
      const path = joinObjectPath([...parentPath, k]);

      out[path] = { op: 'add', value: to[k] };
    }
  }

  return out;
}

export function changesetConflicts(a, b) {
  let keys = Object.keys(a).sort();
  const out = [];
  const seen = {};

  for ( const k of keys ) {
    let ok = true;
    const aa = a[k];
    const bb = b[k];

    // If we've seen a change for a parent of this key before (e.g. looking at `spec.replicas` and there's already been a change to `spec`), assume they conflict
    for ( const parentKey of parentKeys(k) ) {
      if ( seen[parentKey] ) {
        ok = false;
        break;
      }
    }

    seen[k] = true;

    if ( ok && bb ) {
      switch ( `${ aa.op }-${ bb.op }` ) {
      case 'add-add':
      case 'add-change':
      case 'change-add':
      case 'change-change':
        ok = isEqual(aa.value, bb.value);
        break;

      case 'add-remove':
      case 'change-remove':
      case 'remove-add':
      case 'remove-change':
        ok = false;
        break;

      case 'remove-remove':
      default:
        ok = true;
        break;
      }
    }

    if ( !ok ) {
      addObject(out, k);
    }
  }

  // Check parent keys going the other way
  keys = Object.keys(b).sort();
  for ( const k of keys ) {
    let ok = true;

    for ( const parentKey of parentKeys(k) ) {
      if ( seen[parentKey] ) {
        ok = false;
        break;
      }
    }

    seen[k] = true;

    if ( !ok ) {
      addObject(out, k);
    }
  }

  return out.sort();

  function parentKeys(k) {
    const out = [];
    const parts = splitObjectPath(k);

    parts.pop();

    while ( parts.length ) {
      const path = joinObjectPath(parts);

      out.push(path);
      parts.pop();
    }

    return out;
  }
}

export function applyChangeset(obj, changeset) {
  let entry;

  for ( const path in changeset ) {
    entry = changeset[path];

    if ( entry.op === 'add' || entry.op === 'change' ) {
      set(obj, path, entry.value);
    } else if ( entry.op === 'remove' ) {
      remove(obj, path);
    } else {
      throw new Error(`Unknown operation:${ entry.op }`);
    }
  }

  return obj;
}

/**
 * Creates an object composed of the `object` properties `predicate` returns
 */
export function pickBy(obj = {}, predicate = (value, key) => false) {
  return Object.entries(obj)
    .reduce((res, [key, value]) => {
      if (predicate(value, key)) {
        res[key] = value;
      }

      return res;
    }, {});
}

/**
 * Convert list to dictionary from a given function
 * @param {*} array
 * @param {*} callback
 * @returns
 */
export const toDictionary = (array, callback) => Object.assign(
  {}, ...array.map((item) => ({ [item]: callback(item) }))
);

export function dropKeys(obj, keys) {
  if ( !obj ) {
    return;
  }

  for ( const k of keys ) {
    delete obj[k];
  }
}

/**
 * Recursively convert a reactive object to a raw object
 * @param {*} obj
 * @param {*} cache
 * @returns
 */
export function deepToRaw(obj, cache = new WeakSet()) {
  if (obj === null || typeof obj !== 'object') {
    // If obj is null or a primitive, return it as is
    return obj;
  }

  // If the object has already been processed, return it to prevent circular references
  if (cache.has(obj)) {
    return obj;
  }
  cache.add(obj);

  if (Array.isArray(obj)) {
    return obj.map((item) => deepToRaw(item, cache));
  } else {
    const rawObj = toRaw(obj);
    const result = {};

    for (const key in rawObj) {
      if (typeof rawObj[key] === 'function' || typeof rawObj[key] === 'symbol') {
        result[key] = null;
      } else {
        result[key] = deepToRaw(rawObj[key], cache);
      }
    }

    return result;
  }
}

/**
 * Helper function to alter Lodash merge function default behaviour on merging
 * arrays and objects.
 *
 * In rke2.vue, the syncMachineConfigWithLatest function updates machine pool configuration by
 * merging the latest configuration received from the backend with the current configuration updated by the user.
 * However, Lodash's merge function treats arrays like object so index values are merged and not appended to arrays
 * resulting in undesired outcomes for us, Example:
 *
 * const lastSavedConfigFromBE = { a: ["test"] };
 * const currentConfigByUser = { a: [] };
 * merge(lastSavedConfigFromBE, currentConfigByUser); // returns { a: ["test"] }; but we expect { a: [] };
 *
 * More info: https://github.com/lodash/lodash/issues/1313

 * This helper function addresses the issue by always replacing the old array with the new array during the merge process.
 *
 * This helper is also used for another case in rke2.vue to handle merging addon chart default values with the user's current values.
 * It fixed https://github.com/rancher/dashboard/issues/12418
 *
 * If `mutateOriginal` is true, the merge is done directly into `obj1` (mutating it).
 * This is useful in cases like:
 *   machinePool.config = mergeWithReplace(clonedLatestConfig, clonedCurrentConfig, { mutateOriginal: true })
 * where merging into a new empty object may lead to incomplete structure.
 *
 * Use `mutateOriginal` when you want to preserve obj1’s original shape, references,
 * or when assigning the result directly to an existing object.
 *
 * @param {Object} [obj1={}] - The first object to merge
 * @param {Object} [obj2={}] - The second object to merge
 * @param {Object} [options={}] - Options for customizing merge behavior
 * @param {boolean} [options.mutateOriginal=false] - true: mutates obj1
 *                  directly. false: returns a new object
 * @param {boolean} [options.replaceArray=true] - true: replaces arrays in obj1
 *                  with arrays in obj2 when both properties are arrays
 *                  false: default lodash merge behavior - recursively merges
 *                  array members
 */
export function mergeWithReplace(
  obj1 = {},
  obj2 = {},
  {
    mutateOriginal = false,
    replaceArray = true,
  } = {}
) {
  const destination = mutateOriginal ? obj1 : {};

  return mergeWith(destination, obj1, obj2, (obj1Value, obj2Value) => {
    if (replaceArray && Array.isArray(obj1Value) && Array.isArray(obj2Value)) {
      return obj2Value;
    }
  });
}
/**
 * Converts Object into a string of a format "key1, val1, key2, val2"
 * @param {Object} input - KV object to convert
 * @returns string
 */
export const convertKVToString = (input) => {
  if (!input || typeof input !== 'object') return '';

  return Object.entries(input)
    .flatMap(([key, value]) => [key, String(value)])
    .join(',');
};

/**
 * Converts kv string into object
 * @param {string} input - a string of a format "key1, val1, key2, val2"
 * @returns
 */
export const convertStringToKV = ( input ) => {
  if (!input?.trim()) return {};

  const parts = input.split(',').map((part) => part.trim());
  const result = {};

  for (let i = 0; i < parts.length - 1; i += 2) {
    const key = parts[i];
    const value = parts[i + 1];

    // Accept empty keys but ignore undefined values
    if (typeof key === 'string') {
      result[key] = value ?? '';
    }
  }

  return result;
};
